iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0

本文同步發表於個人部落格:FHIR 伺服器的處理錯誤


火線超人可能同時查好幾家醫院的資料。

它的規格裡有一條寫得很死。每一台伺服器的查詢都有各自的逾時上限,預設五秒。一台逾時或出錯,絕對不能影響其他台的結果。例外必須在那一台的查詢內部就攔下來。轉成那台的錯誤狀態,不准往上到 webhook 層。

還有一個地方要特別說明一下。一般查詢預設抓三個月。如果三個月內一筆都沒有、而且至少有一台回 ok,服務會自動改抓十二個月再查一次。但只要每一台都失敗或都需要重新授權,就維持三個月不動。

這條規則背後是一個判斷:「查不到」跟「查失敗」是兩件不同的事。分不清楚的程式會在全部失敗的時候跑去改抓十二個月。做了白工,還讓使用者以為只是資料比較舊。

今天我們就來處理這件事:要怎麼分辨伺服器到底在跟你說什麼呢?

實際觸發五種情形

到目前為止這個 app 的每個請求都成功了。要處理錯誤,得先有錯誤。

我對這台 sandbox 試了五種弄壞它的方式,全部觸發成功:

五種錯誤情境的對照圖,米色底,標題「五種錯誤,四種是 OperationOutcome」,副標「describeFailure() 對五種情境各跑一次,逐筆記下它讀到什麼」。畫面主體是一張表,最上方一排深藍色底白字的欄標題,由左而右依序是情境、狀態碼、Content-Type、issueCode、issueCount。往下五列,第一、二、四、五列都是白底加淺灰外框:第一列情境是讀一個不存在的 id,狀態碼 404,Content-Type 是 application/fhir+json,issueCode 是 processing,issueCount 是 1;第二列情境是用一個不支援的參數,狀態碼 400,其餘三欄與第一列相同;第四列情境是送錯資源型別,狀態碼 400,其餘三欄相同;第五列情境是 body 不是合法 JSON,狀態碼 400,其餘三欄相同。夾在中間的第三列整列是珊瑚色底白字,情境是帶一個亂寫的 token,狀態碼 401 的字比其他列都大,Content-Type 是 text/plain,issueCode 是 null,issueCount 是 0。表格下方一整條深藍色橫幅,左半邊第一行白色粗體寫「401 沒有 OperationOutcome 可以解析」,第二行寫 Content-Type 是 text/plain、issueCode 是 null、issueCount 是 0,這三個值以珊瑚色標示;右半邊上一行灰字寫「無條件呼叫 response.json()」,下一行白色粗體寫「就在這一列丟例外」。圖片最下方一行灰字:三種 400 共用同一句給使用者的訊息,因為這台的 OperationOutcome 沒有一個帶 details.text

第三列就是今天最重要的一件事。

不是每個錯誤都回 OperationOutcome

FHIR 規範定義了 OperationOutcome 這個資源,專門用來回報錯誤。教科書上的錯誤處理就是解析它。

但那個 401 回來的是這樣:

HTTP/1.1 401 Unauthorized
Content-Type: text/plain; charset=utf-8

Invalid token: jwt malformed

沒有 JSON,沒有 issue 陣列,就一句純文字。

原因很合理:這個錯誤是授權層擋下來的,請求根本沒走到 FHIR 伺服器。授權層不是 FHIR 伺服器,它沒有義務回 FHIR 資源。

所以下面這種寫法會在這裡爆掉:

if (!response.ok) {
  const outcome = await response.json()   // 401 走到這裡直接丟例外
  showError(outcome.issue[0].diagnostics)
}

而且爆掉的方式很低級。使用者看到的不是「請重新登入」,是一個 JSON 解析失敗的訊息。那跟真正的問題完全無關,工程師照著去查也查不到。

先看 Content-Type 再決定怎麼解析,這是這篇最實用的一行:

const contentType = response.headers.get('content-type') ?? ''
if (/\bjson\b/i.test(contentType)) {
  // 解析 OperationOutcome
} else {
  // 當純文字讀
}

用正則是因為 media type 的大小寫不固定。Application/FHIR+JSON 也合法,includes('json') 會漏掉它,把一份 OperationOutcome 當純文字讀。

OperationOutcome 裡面該讀哪一層

看一筆實際回來的:

{
  "resourceType": "OperationOutcome",
  "issue": [
    {
      "severity": "error",
      "code": "processing",
      "diagnostics": "Resource Observation/no-such-observation-xyz is not known"
    }
  ]
}

issue陣列,一次可以回報好幾個問題。severity 有四種:fatalerrorwarninginformation。所以要先挑出真正擋住你的那些:

const blocking = issues.filter(
  (issue) => issue.severity === 'error' || issue.severity === 'fatal'
)
const pick = (blocking.length ? blocking : issues)[0]

接下來是關鍵:diagnostics 不能直接拿給使用者看

看看這台實際吐出來的東西:

Failed to parse request body as JSON resource. Error was:
Failed to parse JSON encoded FHIR content: java.io.EOFException:
End of input at line 1 column 31 path $.resourceType

java.io.EOFException 出現在使用者的畫面上,那是把伺服器的內部實作洩漏出去。而使用者完全無法從中知道該怎麼辦。

給人看的那一句應該讀 details.text。那個欄位是規範留給「寫給人看的說明」的地方。但這台的五種錯誤沒有一個帶 details,全部只有 diagnostics

所以要有一套自己準備的訊息,按狀態碼對照:

const BY_STATUS = {
  400: '送出去的內容有問題,請檢查欄位。',
  401: '授權已失效,請重新授權。',
  403: '這個帳號沒有權限做這件事。',
  404: '找不到這筆資料。',
  429: '請求太頻繁,請稍後再試。',
  500: '伺服器出錯了,稍後再試一次。',
}

優先序是:details.text 有就用它,沒有就退到這張表。diagnostics 一律進 console 給工程師,不上畫面。

可重試與不可重試

錯誤要分兩類,因為處置方式完全不同。

可重試的是「再送一次可能就好了」:伺服器忙不過來、暫時性的網路問題、被限流。這類的處置是等一下再試,使用者甚至不必知道。

const RETRIABLE = new Set([429, 500, 502, 503, 504])

不可重試的是你自己送錯了。400 是這個請求伺服器處理不了,401 是授權無效,404 是東西不在。這些重試一百次結果都一樣,重試只是在浪費時間跟伺服器資源。

有一個要特別小心:401 不能無腦重試。你可能會想「token 過期了,refresh 一次再送」。token 剛好過期時,這是對的。

但 401 的成因不只有過期。token 被撤銷、或是格式根本就壞掉,也都是 401。這兩種 refresh 完再送還是 401,就變成無限迴圈。

順帶講一個常見的誤會:scope 不夠通常不是 401。RFC 6750 給它配的是 403 的 insufficient_scope,401 配的是 invalid_token,也就是過期、撤銷、格式錯。拿到那種 403 時 refresh 沒用,要重新授權把缺的 scope 要回來。

fhirclient 的選擇更保守。我翻了它的原始碼,它在送出請求之前就先看要不要換 token。三個條件要一起成立:有 access_token、有 refresh_token、離過期不到十秒。

但 401 真的回來之後,它一次都不重試,直接把整個 session 清掉要你重新授權。debug 訊息寫得很白:Auto-refresh failed! Please re-launch the app.

要誠實講一件事:今天這五種情境全部落在不可重試那一組。可重試那條路的程式碼寫得出來,但你在這裡驗證不了它,別把它當成測過的東西。

可重試與不可重試狀態碼的分組圖,米色底,標題「11 個狀態碼分兩組,實測全落在不重試那邊」,副標「isRetriable() 的判定逐一驗過,珊瑚色是這次真的觸發到的」。畫面上左右兩個區塊,左邊是一個佔了將近三分之二寬度的深藍色大方塊,右邊是一個較窄較矮的白底虛線外框方塊。左方塊上方灰字寫 isRetriable() 回 false,底下白色粗體寫「不重試,6 個」,再往下是兩欄三列共六個色塊:第一列左邊珊瑚色底寫 400 加一行小字「觸發 3 次」,右邊珊瑚色底寫 401 加「觸發 1 次」;第二列左邊深藍色底寫 403 加灰字「未觸發」,右邊珊瑚色底寫 404 加「觸發 1 次」;第三列左右都是深藍色底,分別寫 410 與 422,各配一行「未觸發」。右方塊上方灰字寫 isRetriable() 回 true,底下寫「可重試,5 個」,往下是五個淺灰底方塊,數字全部灰化,依序為 429、500、502、503、504,方塊下方一行灰字寫「五個一次都沒觸發到」。兩個方塊下方是一條白底、左緣有一條珊瑚色直條的橫帶,第一行粗體寫「這台 sandbox 跑不出可重試的情境」,第二行寫五種情境全部落在不重試那一組,可重試那條路寫得出來,但在這裡驗證不了。圖片最下方一行灰字:觸發次數合計 5 次,對應 describeFailure() 跑過的五種情境

主線用的是 client.request(),它丟什麼

上面那些都是自己用 fetch 打出來的。但第三幕的主線是 client.request(),它失敗時丟的東西長得不一樣。

我把 fhirclient 那個 vendor 檔翻開來看了。它丟的是一個 HttpError,繼承自 Error

class HttpError extends Error {
  constructor(response) {
    super(`${response.status} ${response.statusText}\nURL: ${response.url}`)
    this.name = 'HttpError'
    this.response = response
    this.statusCode = response.status
    this.status = response.status
    this.statusText = response.statusText
  }
}

error.message 開頭兩行是狀態碼加 statusText,然後是網址:

404 
URL: https://launch.smarthealthit.org/v/r4/sim/…/fhir/Observation/no-such-observation-xyz

404 後面那個空格不是排版失誤,這台 sandbox 的 statusText 是空字串。

OperationOutcome 呢?直覺會想:Response 就掛在 error.response 上,clone 一份餵給 describeFailure() 不就好了。

這條路走不通。看 fhirclient 收到回應後的那個檢查:

async function checkResponse(response) {
  if (!response.ok) {
    const error = new HttpError(response)
    throw (await error.parse(), error)
  }
  return response
}

throw 之前先 await error.parse()。而 parse() 會讀 body:

async parse() {
  if (!this.response.bodyUsed) {
    const type = this.response.headers.get('content-type') || 'text/plain'
    if (type.match(/\bjson\b/i)) {
      this.message += '\n\n' + JSON.stringify(await this.response.json(), null, 4)
    } else if (type.match(/^text\//i)) {
      this.message += '\n\n' + (await this.response.text())
    }
  }
  return this
}

(這兩段是照 vendor 檔重排的可讀版。OAuth error 那一支跟外層的 try 都省略了。)

它讀 body 是有條件的,Content-Type 要是 JSON 或 text/ 開頭。這五種都符合。

所以你拿到 HttpError 的時候,body 已經被讀完了,bodyUsedtrue。這時候呼叫 clone() 不是拿到一個空的 stream,是直接丟 TypeError

但被讀走的東西沒有消失。parse() 把整個 JSON 用 JSON.stringify 接在 message 後面了。所以剛才那段 message 其實還有下半截:

404 
URL: https://launch.smarthealthit.org/v/r4/sim/…/fhir/Observation/no-such-observation-xyz

{
    "resourceType": "OperationOutcome",
    "issue": [
        {
            "severity": "error",
            "code": "processing",
            "diagnostics": "Resource Observation/no-such-observation-xyz is not known"
        }
    ]
}

OperationOutcome 一直都在,只是變成了文字。

所以這條路要讀的是 statusmessage,不要去碰 response

export function describeClientError(error) {
  if (typeof error?.status === 'number') {
    return {
      status: error.status,
      retriable: isRetriable(error.status),
      userMessage: BY_STATUS[error.status] ?? `請求失敗,HTTP ${error.status}。`,
      developerMessage: error.message ?? null,
      issueCode: null,
      issueCount: 0,
    }
  }
  return {
    status: 0,
    retriable: true,
    userMessage: '連不上伺服器,請檢查網路後再試一次。',
    developerMessage: error?.message ?? String(error),
    issueCode: null,
    issueCount: 0,
  }
}

issueCodeissueCount 固定是 null0,這是走 client.request() 的代價。你拿得到那段文字,但拿不到一個可以讀 issue[0].code 的物件。真的需要結構化的 issue,就得像前面那樣自己用 fetch,在讀 body 之前先 clone()

順帶一提,parse() 判斷 content-type 用的是 contentType.match(/\bjson\b/i)。所以前面那一課不是本系列自己發明的慣例,fhirclient 的判斷順序也是先看 content-type 再決定怎麼讀。

第三類:連狀態碼都沒有

上面兩種都假設伺服器有回東西。還有一種情況是根本沒有回應。

斷網、DNS 查不到伺服器的位址、CORS 預檢被擋,這三種是 fetch 自己 reject。丟的是 TypeError 不是 HttpError,身上連 status 都沒有。

所以判斷式問的是「有沒有一個數字的 status」,這就是上面那段 if (typeof error?.status === 'number') 的用意。沒有 status 的那條路當成可重試,因為網路問題常常是暫時的。

但這個分類會漏。CORS 設定錯誤也沒有 status,重試幾次都一樣。

兩種例外的分岔圖,米色底,標題「細節在 message 裡,不在 response」,副標「讀 fhirclient 2.6.3 的 vendor 檔本體,不是文件也不是推測」。畫面最上方一條橫跨全寬的深藍色橫帶,左邊灰字寫「先問這一個」,接著白色等寬大字 error.status,最右邊灰字寫「兩條路只差這一個判斷」。橫帶底部中央往下引出兩條線,各自轉一個圓角直角再往下:左邊那條是深藍色實線,接到左下方的方塊;右邊那條是灰色虛線,接到右下方的方塊。左下方是一個佔了大半寬度的深藍色方塊,最上面等寬白色大字 HttpError,底下一行灰字寫 response.ok 為 false 時丟出,checkResponse 在 throw 之前已經 await 過 parse()。再往下一行小字標「error.message 實際長這樣」,接一塊更深的深藍色區域,分成三個部分:第一行是珊瑚色的 404,後面空著沒有 statusText;第二行灰字寫 URL 冒號後接 https://launch.smarthealthit.org/v/r4/sim/…/fhir/Observation/no-such-observation-xyz,這一行折成兩行顯示;空一行之後是淺綠色的一段 JSON,內容是 resourceType 為 OperationOutcome、issue 陣列裡 severity 為 error、code 為 processing,後面被刪節號截斷。再往下是三個並排的中藍色小方塊,各有一行灰色小字與一行白色粗體字,依序是第一段配「狀態碼與 statusText」、第二段配 URL、第三段配「parse() 接上的 body」。方塊最底下一行珊瑚色字寫「OperationOutcome 在第三段,是文字不是能讀 issue 的物件」。右下方是一個較窄較矮的白底虛線外框方塊,最上面等寬灰字 TypeError,底下一行寫 fetch 本身就 reject,不是 HttpError。往下一行小字標「這條路上拿得到的」,接一塊淺灰底區域,寫著 error.status 是 undefined,其中 undefined 以珊瑚色粗體標示。再往下三個灰色圓點項目,依序是斷網、DNS 解不出來、CORS 預檢被擋。方塊最底下掛一個淺灰底膠囊標記寫「本次實跑無法穩定觸發」。圖片最下方兩行灰字:body 在 throw 之前就被 parse() 讀掉了,事後 clone() 只會丟 TypeError。要結構化的 issue 就得自己用 fetch,在讀之前先 clone。parse() 的判斷式是 contentType.match(/\bjson\b/i)

這一類我沒辦法給你實測輸出,你把 Wi-Fi 關掉試一次就會看到。

Console 上的字每家瀏覽器不同,Chrome 是 TypeError: Failed to fetch。重點不是那串字,是型別為 TypeError 而不是 HttpError

跟著做:讓錯誤自己現形

起點是 day18 結束時的專案,加上 write.js。今天新增 errors.js,並在畫面上放六顆按鈕故意觸發失敗。

第一步,新增 errors.js

const BY_STATUS = {
  400: '送出去的內容有問題,請檢查欄位。',
  401: '授權已失效,請重新授權。',
  403: '這個帳號沒有權限做這件事。',
  404: '找不到這筆資料。',
  410: '這筆資料已經被刪除。',
  422: '這筆資料沒通過伺服器的檢查。',
  429: '請求太頻繁,請稍後再試。',
  500: '伺服器出錯了,稍後再試一次。',
  503: '伺服器暫時無法服務,稍後再試一次。',
}

const RETRIABLE = new Set([429, 500, 502, 503, 504])

export function isRetriable(status) {
  return RETRIABLE.has(status)
}

function fromOperationOutcome(payload) {
  if (payload?.resourceType !== 'OperationOutcome') return null
  const issues = payload.issue ?? []
  const blocking = issues.filter(
    (issue) => issue.severity === 'error' || issue.severity === 'fatal'
  )
  const pick = (blocking.length ? blocking : issues)[0]
  if (!pick) return null
  return {
    userMessage: pick.details?.text ?? null,
    developerMessage: pick.diagnostics ?? pick.details?.text ?? null,
    issueCode: pick.code ?? null,
    issueCount: issues.length,
  }
}

fromOperationOutcomenull 的情況有兩種:payload 根本不是 OperationOutcome,或者 issue 是空陣列。兩種都要防,因為伺服器回什麼你控制不了。

第二步,主函式先看 Content-Type

export async function describeFailure(response) {
  const status = response.status
  const contentType = response.headers.get('content-type') ?? ''
  const fallback = BY_STATUS[status] ?? `請求失敗,HTTP ${status}。`

  let developerMessage = null
  let issueCode = null
  let issueCount = 0
  let userMessage = null

  if (/\bjson\b/i.test(contentType)) {
    try {
      const parsed = fromOperationOutcome(await response.json())
      if (parsed) {
        userMessage = parsed.userMessage
        developerMessage = parsed.developerMessage
        issueCode = parsed.issueCode
        issueCount = parsed.issueCount
      }
    } catch {
      developerMessage = '回應宣稱是 JSON 但解不開'
    }
  } else {
    developerMessage = (await response.text()).slice(0, 200)
  }

  return {
    status,
    retriable: isRetriable(status),
    userMessage: userMessage ?? fallback,
    developerMessage,
    issueCode,
    issueCount,
  }
}

那個 try 不是多餘的。Content-Type 說是 JSON,不代表 body 真的是合法 JSON。代理伺服器或閘道器塞一頁 HTML 錯誤頁進來,是很常見的事。

.slice(0, 200) 是因為純文字錯誤可能是一整頁 HTML,不截斷會把 console 洗版。

第三步,六顆按鈕

六顆按鈕,前五顆對應前面那五種弄壞的方式:

const FAILURE_CASES = {
  notfound: (base) => [`${base}/Observation/no-such-observation-xyz`, {}],
  badparam: (base) => [`${base}/Observation?totally-not-a-param=1`, {}],
  badtoken: (base, patientId) => [
    `${base}/Patient/${patientId}`,
    { headers: { Authorization: 'Bearer not-a-real-token' } },
  ],
  mismatch: (base) => [
    `${base}/Observation`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/fhir+json' },
      body: JSON.stringify({ resourceType: 'Patient' }),
    },
  ],
  // 不是送錯型別,是根本收不了尾。Content-Type 說是 JSON,body 不是。
  badjson: (base) => [
    `${base}/Observation`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/fhir+json' },
      body: '{"resourceType": "Observation"',
    },
  ],
}

第六顆叫 clientfail,走的是 client.request() 不是 fetch,所以不在表裡:

import { describeFailure, describeClientError } from './errors.js'

async function tryFailure(client, name) {
  try {
    if (name === 'clientfail') {
      await client.request('Observation/no-such-observation-xyz')
      return // 這個請求必定失敗,走到這裡代表伺服器行為與預期不符
    }
    const [url, init] = FAILURE_CASES[name](client.state.serverUrl, client.patient.id)
    report(name, await describeFailure(await fetch(url, init)))
  } catch (error) {
    report(name, describeClientError(error))
  }
}

function report(name, failure) {
  console.log(`[${name}]`, failure.status, failure.retriable ? '可重試' : '不重試')
  console.log('  給使用者:', failure.userMessage)
  console.log('  給開發者:', failure.developerMessage)

  errorResult.className = 'mt-2 text-sm text-rose-700'
  errorResult.textContent = `HTTP ${failure.status}:${failure.userMessage}`
}

errorResult 是畫面上那行紅字,在檔案開頭抓好:

const errorResult = document.querySelector('#error-result')

按鈕的事件綁定放在 showPatient() 裡,要等授權完成拿到 client 才綁得上:

  for (const button of document.querySelectorAll('[data-case]')) {
    button.addEventListener('click', () => tryFailure(client, button.dataset.case))
  }

那個 try 一次管兩件事。一是接住 clientfail 丟的 HttpError,交給 describeClientError。二是接住斷網時 fetch 丟的 TypeError,把 Wi-Fi 關掉再按前五顆就會走到。少了它,斷網就是一個沒人接的 Promise rejection,畫面什麼都不會動。

base 傳的是 client.state.serverUrl。那是 fhirclient 存 FHIR base URL 的地方,day18 寫入時用的也是它。病人 id 從 client.patient.id 傳進去,不要寫死,換一位病人才不用改程式。

第四步,六顆都按一次

實際跑出來的結果:

執行結果圖,淺灰綠底色,標題「六顆按鈕跑出來的樣子」,副標「六顆各按一次,例外都被接住了,六次都印得出一句話」。畫面中央一個白色圓角面板,最上一列是三個灰色小圓點代表瀏覽器視窗,沒有網址列。往下一行灰字寫「六顆按鈕各按一次」,底下排著六顆等寬字按鈕,依序是 notfound、badparam、badtoken、mismatch、badjson、clientfail,其中 badtoken 那顆是白底珊瑚色字加珊瑚色外框,clientfail 那顆是白底深藍色字加深藍色外框,其餘四顆是灰框灰字。再往下是一條淺灰底的 Console 標籤,底下是六段輸出,每段三行,段與段之間有一條細灰線,每段第一行都是中括號包住的按鈕名稱接狀態碼與可重試判定。第一段沒有編號,第一行等寬字是 notfound 接 404 不重試,第二行「給使用者:」接「找不到這筆資料。」,第三行「給開發者:」接 Resource Observation/no-such-observation-xyz is not known。第二段左緣掛綠色編號 1,第一行是 badparam 接 400 不重試,第二行給使用者是「送出去的內容有問題,請檢查欄位。」,第三行給開發者是 Unknown search parameter 引號包住的 totally-not-a-param,接 Value search parameters for this search are 冒號,再接一串以中括號開頭的合法參數清單,開頭是 _id 與 _language,後面被刪節號截斷。第三段左緣掛綠色編號 2,整段的按鈕名稱與狀態碼都是珊瑚色,第一行是 badtoken 接 401 不重試,第二行給使用者是「授權已失效,請重新授權。」,第三行給開發者是珊瑚色的 Invalid token 冒號後接 jwt malformed。第四段沒有編號,第一行是 mismatch 接 400 不重試,第二行給使用者與第二段逐字相同,第三行給開發者是 Failed to parse request body as JSON resource. Error was 冒號後接 Incorrect resource type found, expected 引號包住的 Observation,再被刪節號截斷。第五段沒有編號,第一行是 badjson 接 400 不重試,第二行給使用者與第二段逐字相同,第三行給開發者是同樣的 Failed to parse request body as JSON resource. Error was 冒號後接 Failed to parse JSON encoded FHIR content 冒號再接 java.io.EOFException,被刪節號截斷。第六段左緣掛綠色編號 3,按鈕名稱與狀態碼都是深藍色,第一行是 clientfail 接 404 不重試,第二行給使用者與第一段逐字相同,第三行給開發者佔了四行,第一行只有一個 404 後面空著,第二行是 URL 冒號後接 https 開頭的 sandbox 網址結尾是 Observation/no-such-observation-xyz,空一行之後接一段以左大括號開頭的 JSON,內容是 resourceType 為 OperationOutcome、issue 陣列裡 severity 為 error,後面被刪節號截斷。面板下方三條編號註記:編號 1 寫 badparam、mismatch 與 badjson 的「給使用者」逐字相同,因為這台的 OperationOutcome 沒有一個帶 details.text,三種 400 只能共用同一句;編號 2 寫 badtoken 的 issueCode 是 null、issueCount 是 0,Content-Type 是 text/plain,沒有 OperationOutcome 可以解析,其中 null 與 0 以珊瑚色標示;編號 3 寫 clientfail 是唯一走 describeClientError 的一顆,issueCode 與 issueCount 同樣是 null 與 0,但 OperationOutcome 沒有消失,它被 parse() 接在 message 後面,其中 null 與 0 以深藍色標示

畫面上只出現「給使用者」那一句,java.io.EOFException 那類東西留在 console。

三種 400 的「給使用者」那一句完全相同,因為這台沒有一個 OperationOutcomedetails.text。實際除錯要看 console 那一行,三者各有一個好認的關鍵字。搜尋參數打錯是 Unknown search parameter,資源型別不符是 Incorrect resource type found,body 不是合法 JSON 則會出現 java.io.EOFException

畫面上分不出來的三種錯誤,在 console 裡一眼就分得出來。這就是「diagnostics 留給 console」的實際用處。

notfoundclientfail 也共用同一句,兩者都是 404。但 console 那一行差很多。notfound 印的是 describeFailure()OperationOutcome 挑出來的 diagnosticsclientfail 印的是整個 HttpErrormessage,裡面有 parse() 接上的完整 JSON。

特別看 badtoken 那次。程式沒有丟例外,照樣產出一句可讀的訊息,這就是先看 Content-Type 換來的。

火線超人的三條規則

回到開頭那些規則,現在它們比較好懂了。

每台伺服器各自算逾時,例外就地攔下。 一台醫院掛掉,使用者應該看到其他幾家的資料,加上一句「某某醫院暫時連不上」。整個畫面空白是最糟的選項。

token 過期的伺服器標成需要重新授權,不查詢。 這是把錯誤再細分。有一種失敗的正確處置既不是重試也不是放棄,是請使用者去按一個按鈕。

全部失敗時不改抓十二個月。 因為那時候你根本不知道有沒有資料,改了只是再失敗一次。

三條規則的共同點是一句話。錯誤不是布林值,它有種類,不同種類要走不同的路。

完整可跑的版本在 GitHub 上的 day19-error-handling,想先看跑起來的樣子可以直接開線上版

小結

先看 Content-Type 再解析,diagnostics 留給 console。可重試與不可重試要分開,而 401 不能無腦重試。

明天處理量的問題。今天這位病人只有三筆病況兩筆用藥,資料一頁就裝完了。真實病歷不會這麼客氣,而 FHIR 的分頁機制跟你想像的不一樣。


上一篇
Day18 - 寫回 FHIR
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言